Skip to main content
Version: 1.0.0

Create a lasso selection

In this tutorial, we will show how you can create a lasso selection by creating custom physical actions and side effects.

Create a scatter plot

First, let's create a scatter plot:

muze
.canvas()
.rows(["Miles_per_Gallon"])
.columns(["Horsepower"])
.detail(["Maker"])
.layers([
{
mark: "point",
name: "scatter",
},
])
.data(dm)
.width(600)
.height(600)
.mount("#chart");

Create a physical action

Now, we need to create a custom physical action. Let's name it lassoSelection. We need to set a function which will attach the dom events on the targetEl element which is the svg tracker element of the chart.

This is the full code of physical action function:

ActionModel.for(canvas).registerPhysicalActions({
/* to register the action */
lassoSelection: (firebolt) => (targetEl) => {
let startPos;
let endPos;
let polygon = [];
targetEl.call(
d3Drag()
.on("start", () => {
const event = getEvent();
startPos = {
x: event.x,
y: event.y,
};
polygon.push([startPos.x, startPos.y]);
firebolt.triggerPhysicalAction("lassoSelection", {
criteria: null,
position: startPos,
dragStart: true,
});
})
.on("drag", () => {
const event = getEvent();
endPos = {
x: event.x,
y: event.y,
};
polygon.push([endPos.x, endPos.y]);
firebolt.triggerPhysicalAction("lassoSelection", {
criteria: null,
position: endPos,
dragging: true,
});
})
.on("end", () => {
const event = getEvent();
endPos = {
x: event.x,
y: event.y,
};
const layer = firebolt.context.getLayerByName("scatter");
const points = layer._points.flat();
let foundPoints = [];
let dimensions = [];
points.forEach((point) => {
const exist = pointInPolygon(
[point.update.x, point.update.y],
polygon,
);
if (exist) {
dimensions.push(point.data.Maker);
}
});

polygon.length = 0;
firebolt.triggerPhysicalAction("lassoSelection", {
criteria: dimensions.length
? {
dimensions: [["Maker"], ...dimensions.map((d) => [d])],
}
: null,
position: endPos,
dragEnd: true,
});
}),
);
},
});

Now, let's understand the code of this function step by step.

Here we are using d3 drag function for handling the drag events. We are getting the d3 drag function from muze.utils.

const d3Drag = muze.utils.getD3Drag();

Then we are attaching drag events on the element:

targetEl.call(
d3Drag().on("start", dragStart).on("drag", drag).on("end", dragEnd),
);

Now let's implement the dragStart function:

const dragStart = () => {
const event = muze.utils.getEvent();
startPos = {
x: event.x,
y: event.y,
};
polygon.push([startPos.x, startPos.y]);
firebolt.triggerPhysicalAction("lassoSelection", {
criteria: null,
position: startPos,
dragStart: true,
});
};

Here, the polygon array is storing the x and y positions. Then we trigger the physical action with criteria as null as we don't want any data to be selected. We just send the mouse position in position property.

Now let's understand the drag function:

const drag = () => {
const event = getEvent();
endPos = {
x: event.x,
y: event.y,
};
polygon.push([endPos.x, endPos.y]);
firebolt.triggerPhysicalAction("lassoSelection", {
criteria: null,
position: endPos,
dragging: true,
});
};

Here, again we are pushing the latest mouse positions in the polygon array. Then we are triggering the physical action with criteria as null and passing a property dragging as true so that we in the side effect we can know if user is in dragging state or not.

Finally, we will look at the dragEnd function:

const dragEnd = () => {
const event = getEvent();
endPos = {
x: event.x,
y: event.y,
};
const layer = firebolt.context.getLayerByName("scatter");
const points = layer._points.flat();
let foundPoints = [];
let dimensions = [];
const dimension = layer.config().encoding.x.field;
points.forEach((point) => {
const exist = pointInPolygon([point.update.x, point.update.y], polygon);
if (exist) {
dimensions.push(point.data[dimension]);
}
});

polygon.length = 0;
firebolt.triggerPhysicalAction("lassoSelection", {
criteria: dimensions.length
? {
dimensions: [[dimension], ...dimensions.map((d) => [d])],
}
: null,
position: endPos,
dragEnd: true,
});
};

Here, we are first getting the layer instance from firebolt.context and getting the points array which contains the x, y coordinates of the layer elements. Then we are filtering all those points which lie within the polygon boundary. We use a pointInPolygon function for checking if a point is in polygon. You can read more about the code here. Then we trigger the physical action with the criteria containing the dimensional values of the data.

Map the physical action with brush behaviour

Now we will map the brush behaviour with the lassoSelection action. Also, we are dissociating brush from drag action otherwise brush will be fired multiple times on drag.

// Map brush behaviour with lassoSelection physical action
.registerPhysicalBehaviouralMap({
lassoSelection: {
behaviours: ['brush']
}
})
// Disable the default select behaviour which is dispatched on click event
.dissociateBehaviour(['brush', 'drag'])

Create a lasso side effect

Now we will create a side effect which will create the lasso path element. We draw the path using payload.position which contains the current mouse position.

.registerSideEffects(class Lasso extends SpawnableSideEffect {
constructor (...params) {
super(...params);
this._path = [];
}

static target () {
return 'visual-unit';
}

static formalName () {
return 'lasso';
}

apply (selectionSet, payload) {
const drawingContext = this.drawingContext();
const path = this.createElement(drawingContext.sideEffectGroup, 'path', [1]);

if (payload.dragStart) {
this._path.push('M', payload.position.x, payload.position.y);
} else if (payload.dragging) {
this._path.push('L', payload.position.x, payload.position.y);
} else {
this._path.length = [];
}

/* createElement is a utility method for side effects */
path.attr('d', this._path.join(' '));
path.style('stroke', '#000').style('stroke-width', '2px')
.style('fill', '#62626230');
return this;
}
})

Map brush behaviour with lasso side effect

Finally, we need to map the side effect with the brush behaviour in the canvas.config().

.config({
interaction: {
brush: {
sideEffects: {
lasso: {
enabled: true,
},
},
},
},
})

Example

const { muze, getDataFromSearchQuery } = viz;

const data = getDataFromSearchQuery();
const dm = new DataModel(data);

const ActionModel = muze.ActionModel;
const { SpawnableSideEffect } = muze.SideEffects.standards;

muze
.canvas()
.rows(["Miles_per_Gallon"])
.columns(["Horsepower"])
.detail(["Maker"])
.data(dm)
.layers([
{
mark: "point",
name: "scatter",
},
])
.config({
interaction: {
brush: {
sideEffects: {
lasso: {
enabled: true,
},
},
},
},
})
.width(600)
.height(600)
.mount("#chart");

const d3Drag = muze.utils.getD3Drag();
const getEvent = muze.utils.getEvent;

const pointInPolygon = (point, polygon) => {
let x = point[0],
y = point[1];

let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
let xi = polygon[i][0];
let yi = polygon[i][1];
let xj = polygon[j][0];
let yj = polygon[j][1];

let intersect =
yi > y != yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect) {
inside = !inside;
}
}

return inside;
};

ActionModel.for(canvas)
.registerPhysicalActions({
/* to register the action */
lassoSelection: (firebolt) => (targetEl) => {
let startPos;
let endPos;
let polygon = [];
const dragStart = () => {
const event = getEvent();
startPos = {
x: event.x,
y: event.y,
};
polygon.push([startPos.x, startPos.y]);
firebolt.triggerPhysicalAction("lassoSelection", {
criteria: null,
position: startPos,
dragStart: true,
});
};
const drag = () => {
const event = getEvent();
endPos = {
x: event.x,
y: event.y,
};
polygon.push([endPos.x, endPos.y]);
firebolt.triggerPhysicalAction("lassoSelection", {
criteria: null,
position: endPos,
dragging: true,
});
};
const dragEnd = () => {
const event = getEvent();
endPos = {
x: event.x,
y: event.y,
};
const layer = firebolt.context.getLayerByName("scatter");
const points = layer._points.flat();
let dimensions = [];
const dimension = "Maker";

points.forEach((point) => {
const exist = pointInPolygon(
[point.update.x, point.update.y],
polygon,
);
if (exist) {
dimensions.push(point.data[dimension]);
}
});

polygon.length = 0;
firebolt.triggerPhysicalAction("lassoSelection", {
criteria: dimensions.length
? {
dimensions: [[dimension], ...dimensions.map((d) => [d])],
}
: null,
position: endPos,
dragEnd: true,
});
};
targetEl.call(
d3Drag().on("start", dragStart).on("drag", drag).on("end", dragEnd),
);
},
})
// Map select behaviour with ctrlClick physical action
.registerPhysicalBehaviouralMap({
lassoSelection: {
behaviours: ["brush"],
},
})
// Disable the default select behaviour which is dispatched on click event
.dissociateBehaviour(["brush", "drag"])
.registerSideEffects(
class Lasso extends SpawnableSideEffect {
constructor(...params) {
super(...params);
this._path = [];
}

static formalName() {
return "lasso";
}

static target() {
return "visual-unit";
}

apply(selectionSet, payload) {
const drawingContext = this.drawingContext();
const path = this.createElement(
drawingContext.sideEffectGroup,
"path",
[1],
);

if (payload.dragStart) {
this._path.push("M", payload.position.x, payload.position.y);
} else if (payload.dragging) {
this._path.push("L", payload.position.x, payload.position.y);
} else {
this._path.length = [];
}

/* createElement is a utility method for side effects */
path.attr("d", this._path.join(" "));
path
.style("stroke", "#000")
.style("stroke-width", "2px")
.style("fill", "#62626230");
return this;
}
},
);